在 Rust 的世界裡,每一個方法 (method) 的簽名都像一份清晰的合約。
會告訴你方法的名稱和參數,更精準地揭示了這個操作的意圖:它會讀取、修改,還是會「消耗」掉你的資料?
這都藏在 self
、&self
與 &mut self
這三種「接收者」(receiver) 之中。
我們來了解 Rust 如何透過嚴謹的方法設計,從源頭杜絕意外,引導我們寫出更安全、更可預測的程式碼。
這三種接收者是在定義這個 API 的主要行為,向使用者做出了明確的承諾:
&self
(共享借用):「我只會讀取資料。」 這是一個唯讀操作,保證不會改變物件的狀態,也不會取走其所有權。
&mut self
(獨占借用):「我需要修改資料。」 這個操作會改變物件的狀態,並要求在修改期間擁有獨占存取權。
self
(所有權轉移):「我會拿走資料。」 這個操作會消耗掉物件本身,將其所有權轉移出去,或轉換成一個全新的實例。
簡單來說,方法的接收者讓 API 的使用者在呼叫方法之前,就能清楚預期該操作對資料的「副作用與生命週期」影響,從而避免了那些隱藏在程式碼深處的意外。
Rust 編譯器基於一套「常識性」的規則,為我們進行了自動推斷,不需要手動標註生命週期 (<'a>
)。
主要規則: 當方法接收 &self
或 &mut self
並回傳一個引用時,編譯器會預設回傳的引用與 self
具有相同的生命週期。
背後哲學: 這個設計的基礎是「借來的東西,不應該比出借者活得更久」這一安全原則。
只有當編譯器無法從上下文推斷出引用之間的關係時(回傳的引用可能來自多個不同的輸入參數),我們才需要明確標註生命週期,以協助編譯器驗證程式的安全性。
這三種接收者視為三種不同的 API 設計模式,分別對應我們在之前文章中提到的「讀取、修改、轉移」三種資料命運:
讀取 (&self
):提供一個物件的「視圖」(view),讓使用者可以安全地觀察其狀態,而不必擔心資料被改變或移動。
修改 (&mut self
):提供一個短暫的「獨占期」,讓使用者可以安全地修改物件狀態,同時編譯器會確保不存在資料競爭的風險。
轉移 (self
):代表一個物件生命週期的終結或轉化。它會交出所有權,或將自身拆解、重構成新的值。
選擇哪一種接收者,就是在為您的 API 選擇最恰當的使用情境與心智模型。
清晰的接收者語意有助於我們避開常見的 API 設計反模式:
情境: 一個名為 get_data()
的方法使用了 self
接收者。
意外: 呼叫者只是想讀取一個值,卻發現自己的變數在呼叫後失效了。
情境: 一個修改內部狀態的方法,卻回傳了一個全新的實例,而不是在原地修改。
意外: 產生了不必要的記憶體配置,讓效能成本變得不透明。
情境: 方法內部創建了一個臨時值,並試圖回傳它的引用。
幸運的是: Rust 編譯器會直接拒絕編譯這種程式碼,從根本上杜絕了懸掛指針的風險。
// 錯誤:回傳了一個在方法結束時就被銷毀的臨時值的引用
fn bad_ref(&self) -> &String {
&String::from("hello") // `String` 是臨時創建的
}
正確的設計應該是:
// 方案 1:直接回傳一個帶有所有權的值
fn good_owned(&self) -> String {
self.buf.clone() // 或其他邏輯
}
// 方案 2:回傳一個早已存在的數據的引用
fn good_ref(&self) -> &str {
&self.buf
}
'static
生命周期情境: 當編譯器提示生命週期錯誤時,直接加上 'static
讓它通過。
意外: 這相當於做了一個「這個資料會活到程式結束」的假承諾,通常代表著更深層的設計問題,並可能在未來引發更複雜的編譯錯誤。
Text
結構的 API 設計讓我們透過一個簡單的 Text
結構,來看看這些原則如何應用:
struct Text {
buf: String,
}
impl Text {
// 【讀取】 &self:提供一個零成本的字串視圖 (&str)
// 生命週期被自動推斷為與 &self 相同
fn as_str(&self) -> &str {
&self.buf
}
// 【修改】 &mut self:在原地修改內部緩衝區
fn push(&mut self, s: &str) {
self.buf.push_str(s);
}
// 【轉移】 self:消耗掉 Text 物件,並回傳一個新的 String
fn into_uppercase(self) -> String {
self.buf.to_uppercase()
}
}
在設計鏈式呼叫 (method chaining) 時,Rust 的接收者語意讓成本變得透明:
impl Text {
// 非破壞性操作:回傳一個借用的視圖,可以持續鏈式呼叫
// trim() 回傳的是 &str,生命週期仍然綁定於 self
fn trim_view(&self) -> &str {
self.buf.trim()
}
// 破壞性操作:消耗 self,並回傳一個擁有所有權的新 String
// 這裡的 to_string() 是一個顯性的成本支出點
fn into_trimmed(self) -> String {
self.buf.trim().to_string()
}
}
使用者可以根據需求,自由選擇要一個零拷貝的「視圖」,還是要一個經過轉換、擁有完整所有權的「新值」。
API 的成本模型一目了然。
OOP 語言常把副作用藏在方法裡;Rust 強迫用接收者把副作用顯式化:
// Java:無法從簽名看出此方法會修改內部狀態
list.add(item); // 修改了 list,但簽名沒有表明
// Rust:明確表示此方法會修改自身
// 必須用 `mut` 關鍵字,明確表示這個變數是「可變的」
let mut list = Vec::new();
list.push(item); //
在 GC 世界「鏈式 API」容易默默分配與複製;Rust 把分配放在拿走所有權的那步,讓你看見成本:
// JavaScript:每次方法調用可能產生新對象,但成本不可見
const result = str.trim().toUpperCase().split(" ");
// Rust:明確區分借用鏈與所有權轉移
// 借用鏈:零成本視圖操作
let view = text.as_str().trim();
// 所有權轉移:明確付費點
let owned = text.into_trimmed().to_uppercase();
在您設計自己的方法時,可以參考以下清單:
這個方法只是讀取資料嗎?
&self
,盡可能回傳引用( &str
、&[T]
)。這個方法需要修改資料嗎?
&mut self
,確保可變借用的範圍盡可能小。這個方法是為了轉換資料或轉移所有權嗎?
self
,讓所有權的轉移和成本變得明確。需要回傳引用時,它的生命週期從何而來?
self
的生命週期綁定。只有在必要時,才手動引入泛型生命週期參數。方法的簽名 (signature) 就是一份無法違背的合約,直接告訴你它會對你的資料做什麼。
&self
:「我只會讀,不動你的東西。」 (唯讀、共享)
&mut self
:「我要修改,期間別來煩我。」 (修改、獨占)
self
:「這東西現在歸我了。」 (消耗、轉移所有權)
這份合約還可以明確意圖,把因為「我沒想到它會這樣」而產生的問題根除掉。